写在前面
本文主要介绍Mach-O、编译链接、符号分类(文末有个符号知识题)
符号可能平时开发的时候接触不多,本文会从新手视角介绍一下这个在编译链接阶段默默付出的家伙
一、MachO
1.MachO
-
Mach-O(MachO Object)是macOS、iOS、iPadOS存储程序和库的文件格式。对应系统通过应用二进制接口(application binary interface,缩写为ABI)来运行该格式的文件 -
Mach-O格式用来替代BSD系统的a.out格式。Mach-O文件格式保存了在编译过程和链接过程中产生的机器代码和数据,从而为静态链接和动态链接的代码提供了单一文件格式 -
Mach-O文件中全部由二进制组成,可以理解成文件配置+二进制代码 -
Mach-O是可读可写的(多个目标文件在合并时会去处理符号表,这个过程就是在修改Mach-O)- 在签名前均是可读可写的
- 签名后也是可读可写的,只要重签名即可
2.MachO调用过程
-
调用
fork函数,创建一个process -
调用
execve或其衍生函数,在该进程上加载,执行我们的Mach-O文件。当我们调用execve(程序加载器)内核实际上在执行以下操作:- 将文件加载到内存中
- 开始分析
Mach-O中的mach_header,以确认它是有效的Mach-O文件
二、查看MachO信息
1.查看mach-header
为了方便就新建了一个MacOS的项目代码如下,编译生成可执行文件
#import <Foundation/Foundation.h>
int main(int argc, const char * argv[]) {
@autoreleasepool {
NSLog(@"Hello, World!");
}
return 0;
}
使用如下命令查看mach-header
/// objdump查看
objdump --macho --private-header machO文件
/// otool查看
otool -h machO文件
2.查看__TEXT段
objdump --macho -d machO文件
3.编译链接过程
- 生成目标文件
在编译时编译器干了两件事情:
-
将代码尽可能的转成汇编语言
-
将符号归类——上例使用的
NSLog属于导入符号(存在别的machO文件中)它会在链接时才确定它的内存地址,因此需要暂存起来——放到重定位符号表中(其他用到的系统库API均是如此)- 为什么在链接时才能确定它的内存地址,是因为生成
目标文件时内存没有虚拟化,本machO文件中符号可以通过地址偏移得到,而导入符号(其他machO文件)却不行 - 同时也可以通过查看
重定位符号表来查看API的使用情况
- 为什么在链接时才能确定它的内存地址,是因为生成
/// 查看目标文件的重定向符号表
objdump --macho --reloc 目标文件
- 生成可执行文件 粗略的讲,链接过程是将多个
目标文件的符号表汇总到一张表中(处理目标文件的符号表),最后去生成可执行文件exec
三、符号表
1.符号表
Symbol Table:用来保存符号String Table:用来保存符号的名称Indirect Symbol Table:叫做间接符号表,用来保存使用的外部符号。更准确一点就是使用的外部动态库的符号,是Symbol Table的子集,例如使用Foundation库中的NSLog就是间接符号
使用如下命令就可以查看可执行文件中符号表,其中-p表示不排序,-a表示输出全部符号表,包括调试符号
nm -pa xxx(MachO文件路径)
迷迷糊糊能看到
main、NSlog、objc_autoreleasePoolPop、objc_autoreleasePoolPush等输出,这不正就是我们代码中的main函数执行嘛!
但是每次使用nm -pa xxx(MachO文件路径)总归有点麻烦,好在我们可以使用脚本(脚本是真的香)
nm -pa ${BUILD_DIR}/${CONFIGURATION}${EFFECTIVE_PLATFORM_NAME}/* > /dev/ttys000
nm:在linux中列出目标文件的符号清单,常用来查看动态链接库中的函数-p:不排序符号,使用该选项后的输出没有按照地址也没有按照符号名称排序-a:输出全部符号表,包括调试符号${BUILD_DIR}/${CONFIGURATION}${EFFECTIVE_PLATFORM_NAME}/*:Xcode内置的参数以便于使用相对路径来执行命令/dev/ttys000:终端窗口。可以在终端窗口使用tty查看当前终端
也可以在项目根目录下新建一个build.sh,在文件中添加需要执行的脚本命令,同时在Run Script中进行配置脚本(有可能需要赋予执行权限)
从这个图可以看出链接主程序->脚本运行->签名应用
2.调试符号
- 文件通过汇编器生成目标文件时 会生成一个
DWARF格式的调试文件,它被放在machO文件中的DWARF段; - 在目标文件中只是一个
section段,不会跟符号表产生关系; - 而在链接过程中
DWARF段会被干掉并放到可执行文件的符号表中
3.剥离调试符号
方案一:Xcode中给我们提供了Strip Symbols选项
但是编译之后终端输出没有任何变化,这是因为
剥离符号是在执行脚本之后的
方案二:我们可以通过设置链接器参数来修改链接时的配置,具体可以通过man ld在终端中查看,从而会发现-S参数可以剥离调试符号
那么具体怎么配置呢?
- 新建
Configuration文件 - 将
Product和Configuration文件一一对应起来 - 配置
Configuration文件:OTHER_LDFLAGS = -Xlinker -S-Xlinker表示后面的参数是传给链接器的
- 编译之后在
BuildSettings中的other link flag中查看是否添加成功
四、符号表分类
1.全局符号和静态符号
将代码改写——添加全局变量和静态变量
#import <Foundation/Foundation.h>
// 全局变量
int global_num = 10;
int global_undefine_num;
// 静态变量
static int static_num = 10;
static int static_undefine_num;
int main(int argc, const char * argv[]) {
@autoreleasepool {
NSLog(@"%d-%d", static_num, static_undefine_num);
}
return 0;
}
使用如下命令行查看可执行文件(剥去调试符号更容易查看)
objdump --macho -syms machO文件
从终端输出可以看出:
-
不管是否初始化,
全局变量都变成了全局符号 -
静态变量都变成了本地符号- 这里需要注意的是,如果
静态变量未使用的话,是会变成调试符号的
- 这里需要注意的是,如果
1.1 全局符号与本地符号
全局符号和本地符号的本质区别是其可见性(visibility),可见性分为两种:
default:用它定义的符号将被导出hidden:用它定义的符号将不被导出
隐藏全局符号有两种方法:
- 使用
static修饰(最为简单) - 修改其可见性(全局符号转为本地符号,且未初始化的全局变量会被存放在未初始化的变量区中)
int global_num __attribute__((visibility("hidden"))) = 10;
int global_undefine_num __attribute__((visibility("hidden")));
1.2 二级命名空间
-
动态库实现不对外声明的全局符号+主项目只做声明全局符号
- 输出结果为动态库的代码 => 全局符号对整个项目可见
-
动态库实现不对外声明的全局符号+主项目声明&实现全局符号
- 输出结果为主工程的代码 => 全局符号对整个项目可见
- 这是由于二级命名空间的缘故——链接器默认采用二级命名空间,除了记录符号名称,还会记录符号属于哪个可执行文件 => 优先使用本工程的符号
-
动态库实现对外声明的全局符号+主项目声明&实现全局符号
- 报错
/Users/felix/Desktop/FXDemo/FXDemo/ViewController.m:18:6: Redefinition of 'global_symbol' - 因为动态库的全局符号对外导出了,在主工程会重新加入符号表
- 如果不导入声明文件就不会报错
- 报错
-
主项目两个不同文件声明同一个全局符号
- 报错
1 duplicate symbol for architecture arm64 - 因为两个符号命名空间一样
- 报错
1.3 全局符号总结
全局符号对整个项目可见;本地符号对当前文件可见
- 动态库中的全局符号,仅在主项目中声明也可以使用;
- 动态库中的静态符号,在其他项目中都不可使用
- 在主项目、动态库中分别声明同一名称的符号,就牵扯到
二级命名空间问题 - 同一项目中不能存在多个全局符号(因为二级命名空间一样)
二级命名空间&一级命名空间,链接器默认会采用二级命名空间,也就是除了记录符号之外,还会记录符号属于哪个machO的,比如记录NSLog属于Foundation
2.导入符号和导出符号
继续拿刚才的NSLog举例:
- 对于本machO文件来说,导入了
NSLog符号(导入符号) - 对于Foundation来说,它导出了
NSLog符号(导出符号)
可以使用命令行查看本文件中的导出符号
objdump --macho --exports-trie machO地址
-
将
导出符号结果与全局符号结果相比较,可以看出导出符号一定是全局符号,因为它对整个项目都可见,且提供给别的项目使用- 由于符号表是占体积的,我们可以通过剥离符号来减少App体积
- 而使用到的
导出符号NSLog将作为间接符号保存起来,这部分符号是不能被脱去的,否则程序无法正常运行 - 从
导出符号一定是全局符号这个结论可知,全局符号也是不能被脱去的
间接符号表用来保存外部符号,即导出符号,可以使用命令行查看本文件中使用到的间接符号表
objdump --macho --indirect-symbols machO地址
-
平时在定义
全局符号/全局变量的时候,需要注意它在编译时会作为导出符号被别的空间/模块所使用 -
一般情况下,
全局符号是导出符号,但这不是绝对的,我们可以通过链接器来控制它- 以动态库举例,它只需要在链接的时候提供
导出符号即可,但Objective-C中所有类默认都是导出符号 - 新建
FXPerson的Objective-C对象,再去查看导出符号 - 即便把Objective-C对象的声明从
.h文件放到.m文件中,也丝毫不会改变它创建了一个导出符号的结果
- 以动态库举例,它只需要在链接的时候提供
可以通过在
Xcconfig文件中这么定义,就能指定对应的“导出符号”不导出——不但可以减少App体积,同时无法通过符号访问对应类会更加安全(OC类默认是导出的)
// 剥离调试符号
OTHER_LDFLAGS = ${inherited} -Xlinker -S
// 剥离FXPerson元类导出符号
OTHER_LDFLAGS = ${inherited} -Xlinker -unexported_symbol -Xlinker _OBJC_METACLASS_$_FXPerson
// 剥离FXPerson类导出符号
OTHER_LDFLAGS = ${inherited} -Xlinker -unexported_symbol -Xlinker _OBJC_CLASS_$_FXPerson
// -unexported_symbol_list可以指定一个需要剥离文件的符号
// -map导出当前machO文件的符号信息以及链接其他库的信息
OTHER_LDFLAGS = ${inherited} -Xlinker -map -Xlinker 地址
3.弱引用符号和弱定义符号
-
弱引用符号(Weak Reference Symbol)如果链接器找不到该符号的定义,则将其设置为0。链接器会将此符号设置为弱链接标志- 关键字为
weak import - 可以只做声明不做实现——需要判空使用
- 不配置链接器参数会报错——
Undefined symbol: _weak_import_function - 配置链接器参数为
-U(告诉链接器这个符号是动态链接的,在编译时不需要理会) OTHER_LDFLAGS = ${inherited} -Xlinker -U -Xlinker _weak_import_function- 作用:避免找不到符号实现而崩溃
- 关键字为
-
弱定义符号(Weak Defintion Symbol)如果链接器为此符号找到了另一个非弱定义,则弱定义将被忽略- 关键字为
weak - 本身是一个
全局符号/导出符号 - 只做声明不做实现会报错
- 声明+多个实现不会报错——动态运行会使用最先找到的弱定义符号,其他都将被忽略
- 作用:避免多个全局符号的实现冲突
- 关键字为
4.重新导出符号
像NSLog这种导入符号在machO文件中是UND未定义的
-
如果别的可执行文件想重新使用这个符号的话,需要重新导出——放到本文件的导出符号表中——外界可以使用这个符号
-
那么就需要用到链接器中的参数
-alias(起别名)会把间接符号表变成导出符号- 仅限间接符号可以这么使用
5.Swift符号
添加一个Swift文件
import Foundation
private class SwiftPerson {
func playGame() {
}
}
public class PublicPerson {
func playGame() {
}
}
使用命令行查看符号表并过滤
objdump --macho -syms machO文件 | grep 'Person'
- Swift文件会生成很多符号
public和private对应着全局符号和本地符号BuildSettings中有配置项可以对Swift符号进行剥离——Strip Swift Symbols
五、Strip原理
1.Strip流程
strip实际上在修改macho中的内容
静态库、目标文件:Mach-O解析成模型->遍历load command->找到__DWARF段进行移除->删除对应的符号->将新模型写入Mach-O动态库、可执行文件:遍历符号表->删除对应的符号
2.剥离符号表
-
动态库要留下导出符号供外部使用- 不能剥离
全局符号/导出符号 Non-Global Symbols——n_type不包含N_EXT
- 不能剥离
-
静态库是目标文件的合集+重定位符号表,只能接触到调试符号- 只能剥离
调试符号 Debug Symbols——n_type包含N_STAB(0xe0)
- 只能剥离
-
App不需要供外部使用,但是需要保留外部导入的符号- 不能剥离
间接符号表/导入符号(NSLog) All Symbols——除了间接符号表中的全部符号
- 不能剥离
写在后面
就符号而言,App链接同等代码量的静态库和动态库,哪个包体积更小?
-
静态库的所有符号都会放到主工程中的符号表中——可能有
全局符号、本地符号、导出符号等(除了导入符号)- 而App中除了
导入符号,其他全部可以被剥离
- 而App中除了
-
动态库的
导出符号都会放到主工程的间接符号表中- 动态库的
导出符号不会被剥离
- 动态库的
所以App链接静态库的体积会小于动态库
附录:xcconfig&shell总结
- xcconfig
// 剥离调试符号
//OTHER_LDFLAGS = ${inherited} -Xlinker -S
// 剥离FXPerson元类&类导出符号
//OTHER_LDFLAGS = ${inherited} -Xlinker -unexported_symbol -Xlinker _OBJC_METACLASS_$_FXPerson -Xlinker -unexported_symbol -Xlinker _OBJC_CLASS_$_FXPerson
// -unexported_symbol_list可以指定一个需要剥离文件的符号
//OTHER_LDFLAGS = ${inherited} -Xlinker -unexported_symbol_list 文件地址
// -map导出当前machO文件的符号信息以及链接其他库的信息
//OTHER_LDFLAGS = ${inherited} -Xlinker -map -Xlinker 文件地址
// 标记符号必须定义
//OTHER_LDFLAGS = ${inherited} -Xlinker -u -Xlinker xxx
// 标记符号为动态链接
//OTHER_LDFLAGS = ${inherited} -Xlinker -U -Xlinker xxx
// 起别名(仅限间接符号表)
//OTHER_LDFLAGS = ${inherited} -Xlinker -alias -Xlinker _NSLog -Xlinker FX_NSLog
- shell
# machO地址
machAddress="${BUILD_DIR}/${CONFIGURATION}${EFFECTIVE_PLATFORM_NAME}/*"
# 目标地址
tty="/dev/ttys001"
# 开始输入
echo "当前时间为$(date +%Y年%m月%d日%H时%M分%S秒)" > ${tty}
echo "********************开始输入********************" > ${tty}
# 查看mach-Header
# objdump --macho --private-header ${machAddress} > ${tty}
# otool -h ${machAddress} > ${tty}
# 查看machO中的__TEXT段
# objdump --macho -d ${machAddress} > ${tty}
# 查看所有符号
# nm -pa ${machAddress} > ${tty}
# 查看符号表
# objdump --macho -syms ${machAddress} > ${tty}
# 查看导出符号(导出给其他项目使用的符号)
# objdump --macho --exports-trie ${machAddress} > ${tty}
# 查看间接符号表(项目中使用到的其他项目中的导出符号)
# objdump --macho --indirect-symbols ${machAddress} > ${tty}
# 查看重定位符号
# objdump --macho --reloc ${machAddress} > ${tty}
# 结束输入
echo "********************结束输入********************" > ${tty}